昨天我們已經能從向量庫撈出最相關的 chunks。今天要把檢索接上語言模型:用 qwen3:8b 根據撈回來的內容生成答案,並且同時顯示答案引用了哪些來源。做完之後,你就擁有一個可以運作的 RAG 系統,也是 DAY27 開始要評估的對象。
整條鏈的資料流向如下:
問題 ─┬─▶ 檢索器 ─▶ 相關 chunks ─┐
│ ├─▶ Prompt ─▶ qwen3:8b ─▶ 答案
└──────────────────────────┘
問題會同時走兩條路:一條進檢索器撈出參考資料,另一條原封不動帶到 prompt。兩者在 prompt 裡會合,一起交給模型。
檢索器是對向量庫的一層包裝,輸入問題,輸出相關的 Document 列表。先在 build_rag.py 更新匯入並加入設定:
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
LLM_MODEL = "qwen3:8b"
注意第一行要把原本的 OllamaEmbeddings 匯入一起改掉。接著測試檢索器:
def get_retriever(vectorstore, k: int = 4):
return vectorstore.as_retriever(search_kwargs={"k": k})
k 是每次檢索回傳幾個 chunks,也就是昨天 similarity_search 的 k。我們先設 4,這也是之後 DAY29 調參的對象之一。
Prompt 是 RAG 品質的關鍵之一。它必須明確告訴模型:只能根據參考資料回答,找不到就說不知道。
PROMPT = ChatPromptTemplate.from_template(
"""你是一位嚴謹的技術問答助理,請使用繁體中文回答。
請「只根據」下方提供的參考資料回答問題。
如果參考資料中找不到答案,請直接回答「根據提供的資料,我無法回答這個問題」,不要自行補充或編造。
參考資料:
{context}
問題:{question}
回答:"""
)
def format_docs(docs):
return "\n\n".join(f"[{i}] {doc.page_content}" for i, doc in enumerate(docs, 1))
format_docs 把檢索到的 chunks 編號後串成一段文字,填進 {context}。昨天做的實驗已經證明,向量搜尋不管相不相關都會回傳 k 筆結果,所以這句「找不到就說不知道」不是多餘的,它是擋住幻覺的第一道防線。
LCEL(LangChain Expression Language)用 | 符號把元件串成管線,前一個的輸出會成為後一個的輸入。
def get_llm():
return ChatOllama(
model=LLM_MODEL,
base_url=OLLAMA_URL,
temperature=0,
reasoning=False,
)
def build_rag_chain(vectorstore, k: int = 4):
retriever = get_retriever(vectorstore, k)
generate = (
RunnableLambda(
lambda x: {"context": format_docs(x["context"]), "question": x["question"]}
)
| PROMPT
| get_llm()
| StrOutputParser()
)
return {
"context": retriever,
"question": RunnablePassthrough(),
} | RunnablePassthrough.assign(answer=generate)
這段是今天最重要的部分,逐段拆解:
| 片段 | 作用 |
|---|---|
{"context": retriever, "question": RunnablePassthrough()} |
同一個問題同時送進兩處:retriever 撈出 chunks,RunnablePassthrough() 把問題原樣保留 |
RunnablePassthrough.assign(answer=generate) |
保留前面的 context 與 question,並額外加上 answer 欄位 |
generate |
把 chunks 格式化 → 套入 prompt → 交給模型 → 取出純文字答案 |
關於兩個模型設定:
temperature=0:讓輸出盡量穩定,問答與評估場景通常不需要創意。reasoning=False:qwen3 預設會先進入「思考模式」,產生一大段推理過程才回答。RAG 問答不需要,關掉可以讓回應更快、輸出更乾淨。加入一個提問函式:
def ask(chain, question: str):
result = chain.invoke(question)
print(f"\n問題:{question}")
print(f"回答:{result['answer']}")
print("來源:")
for doc in result["context"]:
source = os.path.basename(doc.metadata["source"])
print(f" - {source}(起始位置 {doc.metadata.get('start_index')})")
再把 __main__ 改成最終版本(沿用昨天的 REBUILD 機制):
REBUILD = False # 修改文件內容或切割參數後,才需要改成 True
if __name__ == "__main__":
if REBUILD or not os.path.exists(PERSIST_DIR):
docs = load_documents()
chunks = split_documents(docs)
vectorstore = build_vectorstore(chunks)
else:
vectorstore = get_vectorstore()
chain = build_rag_chain(vectorstore)
ask(chain, "200 和 201 狀態碼有什麼差別?")
ask(chain, "git revert 和 git reset 有什麼不同?")
ask(chain, "docker run -p 8080:80 是什麼意思?")
ask(chain, "ACID 的四個特性是什麼?")
執行:
uv run python build_rag.py
第一次呼叫 qwen3:8b 需要先把模型載入記憶體,會等比較久,之後就快了。
請對每一題檢查三件事:
data/ 裡的原文確認接著重現昨天的實驗,但這次是讓模型來回答:
ask(chain, "今天台中的天氣如何?")
因為檢索器仍然會回傳 4 筆「最接近」的 chunks,但內容和天氣毫無關係,模型應該會依照 prompt 的指示,回答它無法回答。
今天完成了第一版完整的 RAG,明天開始進入評估:我們會準備 20 到 30 筆有標準答案的測試問題,讓這個 RAG 跑一遍,並用 RAGAS 的四個核心指標量化它的表現,建立第一份 baseline。